LIKE 검색이 인덱스를 타는 조건
LIKE 검색이 인덱스를 타는 조건
LIKE 'ABC%'는 정렬된 B+Tree에서 ABC로 시작하는 연속 범위의 시작과 끝을 찾을 수 있다. 반면 LIKE '%ABC%'는 첫 글자를 알 수 없어 일반 B+Tree의 한 구간으로 좁히기 어렵다. 실제 인덱스 사용 여부에는 패턴뿐 아니라 collation, 열에 적용한 함수와 형 변환, 복합 인덱스의 앞 열, 반환 행 비율도 영향을 준다.
목차
- #문제가 되는 상황
- #접두 검색이 범위 탐색이 되는 이유
- #앞에 와일드카드가 오면 시작점을 찾을 수 없다
- #밑줄과 퍼센트 문자를 검색할 때
- #대소문자와 collation이 검색 의미를 정한다
- #열에 함수를 적용하면 일반 인덱스를 쓰기 어렵다
- #복합 인덱스에서 LIKE가 놓이는 위치
- #LIKE가 인덱스를 사용해도 느릴 수 있다
- #접미 검색을 위한 뒤집은 값 인덱스
- #중간 단어 검색은 다른 자료구조를 검토한다
- #검색어 입력과 보안에서 주의할 점
- #실행 계획과 테스트 데이터로 확인하기
- #결론
- #관련 노트
문제가 되는 상황
상품 SKU와 상품명 검색 API를 만든다고 하자. 다음 두 쿼리는 모두 LIKE를 쓰지만 인덱스 관점에서는 전혀 다르다.
SELECT id, sku
FROM products
WHERE sku LIKE 'CAM-2026%';
SELECT id, name
FROM products
WHERE name LIKE '%camera%';
첫 번째는 SKU가 특정 문자열로 시작하는 상품을 찾는다. 두 번째는 상품명 어느 위치에든 camera가 들어가면 된다. “LIKE는 인덱스를 타지 않는다” 또는 “문자열 열에 인덱스가 있으니 둘 다 빠르다”라는 설명은 둘 다 충분하지 않다.
핵심 질문은 정렬된 인덱스에서 검색을 시작할 위치와 끝낼 위치를 패턴만으로 결정할 수 있는가다.
접두 검색이 범위 탐색이 되는 이유
sku에 B+Tree 인덱스가 있다고 하자.
CREATE INDEX ix_products_sku
ON products(sku);
리프 엔트리는 collation의 비교 규칙에 따라 정렬된다. 개념적으로 다음과 같다.
BAT-100
CAM-2025-001
CAM-2026-001 ← 'CAM-2026%' 시작
CAM-2026-002
CAM-2026-PRO
CAN-100 ← 범위 종료
CAR-200
CAM-2026이라는 고정 접두가 있으므로 DB는 그 값이 시작되는 리프를 탐색하고 접두가 달라지는 지점에서 멈출 수 있다.
flowchart LR
A[B+Tree에서 CAM-2026 시작점 탐색] --> B[연속된 leaf 엔트리 읽기]
B --> C{접두가 계속 일치?}
C -->|예| B
C -->|아니오| D[범위 스캔 종료]이는 논리적으로 다음과 비슷한 범위 접근을 가능하게 한다. 실제 경계 계산은 문자 집합과 collation에 따라 엔진이 처리한다.
sku >= 'CAM-2026...'
sku < '다음 접두 경계...'
따라서 고정 접두 뒤에 %가 오는 패턴은 일반적으로 인덱스 range scan 후보가 된다.
WHERE sku LIKE 'CAM%'
WHERE sku LIKE 'CAM-2026%'
WHERE sku LIKE 'CAM-2026-___'
세 번째도 CAM-2026-까지 고정되어 있어 시작 범위를 찾을 수 있다. 다만 _는 임의의 한 문자를 뜻하므로 이후 조건은 추가 필터로 평가될 수 있다.
앞에 와일드카드가 오면 시작점을 찾을 수 없다
중간 부분 문자열 검색은 첫 문자가 무엇인지 알 수 없다.
WHERE name LIKE '%camera%'
정렬된 목록에서 camera를 포함하는 값은 접두에 따라 흩어진다.
Action Camera
Digital Camera Bag
Home Camera Stand
Pocket Camera
Waterproof Camera Case
각 문자열의 첫 글자가 다르므로 B+Tree의 연속된 하나의 구간으로 이동할 수 없다. 인덱스의 모든 엔트리를 읽으며 문자열을 검사하거나 테이블 전체를 스캔해야 할 수 있다.
접미 검색도 같다.
WHERE filename LIKE '%.jpg'
WHERE email LIKE '%@example.com'
정렬 기준은 문자열의 앞부분이므로 끝부분만 알아서는 시작 위치를 정할 수 없다.
| 패턴 | 고정된 시작 접두 | 일반 B+Tree 범위 탐색 |
|---|---|---|
'camera' |
전체 문자열 | 동등 탐색 가능 |
'camera%' |
camera |
가능 |
'cam_ra%' |
cam까지 |
접두 범위 + 나머지 필터 가능 |
'%camera' |
없음 | 한 범위로 좁히기 어려움 |
'%camera%' |
없음 | 한 범위로 좁히기 어려움 |
옵티마이저가 폭이 작은 인덱스 전체를 스캔할 수는 있다. 실행 계획에 index가 보이더라도 수백만 엔트리를 처음부터 읽었다면 원하는 의미의 빠른 탐색은 아니다.
밑줄과 퍼센트 문자를 검색할 때
LIKE에서 %는 0개 이상의 임의 문자, _는 정확히 한 개의 임의 문자를 뜻한다.
WHERE code LIKE 'AB_%'
이 패턴은 ABX, AB_123, ABCD 등 AB 뒤에 최소 한 문자가 있는 값을 포함할 수 있다. 실제 밑줄 문자를 찾으려면 escape 규칙을 사용해야 한다.
WHERE code LIKE 'AB\_%' ESCAPE '\';
사용자 입력에 %나 _가 포함되면 검색 의미가 의도치 않게 넓어진다. 리터럴 부분 검색을 제공하려면 애플리케이션에서 LIKE 메타문자를 이스케이프하고, DB 드라이버와 SQL mode의 백슬래시 처리 차이를 확인한다.
function escapeLikeLiteral(value: string): string {
return value
.replaceAll("\\", "\\\\")
.replaceAll("%", "\\%")
.replaceAll("_", "\\_");
}
const pattern = `${escapeLikeLiteral(input)}%`;
값은 반드시 바인딩 파라미터로 전달한다.
SELECT id, code
FROM products
WHERE code LIKE :pattern ESCAPE '\';
대소문자와 collation이 검색 의미를 정한다
Camera, camera, CAMERA를 같은 문자열로 볼지는 인덱스 존재보다 collation에 의해 결정된다. 대소문자를 구분하지 않는 collation이라면 다음 패턴이 세 값을 모두 찾을 수 있다.
WHERE name LIKE 'camera%'
accent-insensitive 규칙에서는 cafe와 café가 같게 비교될 수도 있다. 반대로 binary 또는 case-sensitive collation은 바이트 또는 대소문자를 구분한다.
case-insensitive: Camera ≈ camera
case-sensitive: Camera ≠ camera
accent-insensitive: cafe ≈ café
검색 UX를 먼저 정하고 열과 인덱스의 collation을 맞춰야 한다. 쿼리에서 매번 다른 collation으로 강제 변환하면 기존 인덱스 정렬 규칙과 맞지 않아 효율적인 범위 탐색이 어려워질 수 있다.
-- 의도는 명확할 수 있지만 실행 계획을 반드시 확인한다.
WHERE name COLLATE some_other_collation LIKE 'camera%'
다국어 상품명에서는 단순 대소문자 변환만으로 검색 의미를 정의하기 어렵다. 언어별 정렬, 유니코드 정규화, 오탈자와 형태소까지 요구한다면 전문 검색 계층을 검토해야 한다.
열에 함수를 적용하면 일반 인덱스를 쓰기 어렵다
다음 쿼리는 name 원본 값이 아니라 LOWER(name) 결과로 비교한다.
SELECT id, name
FROM products
WHERE LOWER(name) LIKE 'camera%';
일반 인덱스가 name의 원본 정렬 값을 보관한다면, 각 행에 함수를 적용한 결과의 정렬 위치를 바로 알 수 없다. 함수 기반 또는 표현식 인덱스를 지원하는 DB라면 그 표현식을 인덱싱할 수 있다.
-- DB별 지원 문법을 확인해야 하는 개념 예시
CREATE INDEX ix_products_lower_name
ON products((LOWER(name)));
또는 정규화 열을 명시적으로 저장한다.
ALTER TABLE products
ADD COLUMN normalized_name VARCHAR(300) NOT NULL;
CREATE INDEX ix_products_normalized_name
ON products(normalized_name);
function normalizeSearchName(value: string): string {
return value.normalize("NFC").trim().toLocaleLowerCase("ko-KR");
}
정규화 열을 사용하면 쓰기 경로마다 동일한 규칙을 적용하거나 generated column을 사용할 수 있다. 규칙이 바뀔 때 기존 데이터를 다시 계산해야 한다는 점도 계획한다.
함수뿐 아니라 암시적 형 변환도 주의한다.
WHERE numeric_code LIKE '123%'
숫자 열을 문자열 패턴으로 비교하면 변환과 의미가 불명확해진다. 검색용 식별자는 타입을 명확히 하고 조건 파라미터 타입도 열과 맞춘다.
복합 인덱스에서 LIKE가 놓이는 위치
tenant별 SKU 접두 검색을 위해 다음 인덱스를 생각할 수 있다.
CREATE INDEX ix_products_tenant_sku
ON products(tenant_id, sku);
SELECT id, sku
FROM products
WHERE tenant_id = 10
AND sku LIKE 'CAM%';
tenant_id가 등호로 고정되고 sku의 접두 범위가 이어지므로 좁은 연속 구간을 만들 수 있다.
(tenant=10, sku='CAM...') 시작
→ 같은 tenant의 CAM 접두만 읽음
반면 앞 열 조건이 없으면 sku가 tenant별로 다시 정렬되어 있어 전체 SKU 접두가 한 구간이 아니다.
WHERE sku LIKE 'CAM%'
(tenant_id, sku)만으로 모든 tenant의 CAM 구간을 곧바로 하나로 찾기 어렵다. 전역 검색이 핵심이라면 (sku, tenant_id)나 검색 전용 구조를 별도로 검토한다.
LIKE 접두는 범위 조건이다. 그 뒤 열은 주요 탐색 구간을 추가로 좁히는 데 제한이 생길 수 있다.
INDEX (tenant_id, sku, created_at)
WHERE tenant_id = 10
AND sku LIKE 'CAM%'
AND created_at >= '2026-08-01'
created_at은 인덱스 필터나 covering에 쓰일 수 있지만 CAM 접두 안에서 날짜가 하나의 연속 범위로 모이지는 않는다. 이는 복합 인덱스의 왼쪽 접두 규칙의 첫 범위 조건과 같은 원리다.
LIKE가 인덱스를 사용해도 느릴 수 있다
접두 검색이 range scan을 하더라도 너무 많은 행이 일치하면 이점이 작다.
WHERE name LIKE 'A%'
전체 상품의 40%가 A로 시작하고 SELECT *라면 수많은 본 테이블 lookup이 발생할 수 있다. 옵티마이저가 전체 스캔을 선택하거나 인덱스를 사용해도 여전히 느릴 수 있다.
짧은 접두보다 사용자가 두세 글자 이상 입력했을 때만 서버 검색을 실행하도록 UI 정책을 둘 수 있다.
if (normalizedQuery.length < 2) {
return { items: [], reason: "QUERY_TOO_SHORT" };
}
페이지 크기와 최대 결과도 제한한다.
SELECT id, sku, name
FROM products
WHERE tenant_id = :tenant_id
AND normalized_name LIKE :prefix_pattern
ORDER BY normalized_name, id
LIMIT 50;
Covering을 위해 작은 결과 열을 인덱스에 추가하는 방법도 있지만 긴 상품명까지 포함하면 인덱스가 커진다. 읽기와 쓰기 비용은 Covering Index로 테이블 접근 줄이기에서 다룬 기준으로 비교한다.
접미 검색을 위한 뒤집은 값 인덱스
파일 확장자나 도메인처럼 접미 검색 요구가 제한적이고 규칙적이라면 문자열을 뒤집어 접두 검색으로 바꿀 수 있다.
original: report.final.pdf
reversed: fdp.lanif.troper
suffix '.pdf' 검색
→ reversed_name LIKE 'fdp.%'
CREATE INDEX ix_files_reversed_name
ON files(reversed_name);
SELECT id, filename
FROM files
WHERE reversed_name LIKE :reversed_prefix;
generated column이나 쓰기 시 계산 열로 유지할 수 있다. 하지만 사용자가 임의의 중간 문자열을 검색하는 요구에는 도움이 되지 않고 저장과 갱신 비용이 늘어난다. 확장자는 별도 열로 모델링하는 편이 더 명확할 수 있다.
CREATE INDEX ix_files_extension
ON files(extension);
즉 뒤집기 기법은 검색 도메인이 접미에 고정된 좁은 문제일 때만 고려한다.
중간 단어 검색은 다른 자료구조를 검토한다
상품명과 문서 본문에서 임의 단어를 찾는 요구는 일반 B+Tree보다 역색인 계열이 자연스럽다.
문서 1: "mirrorless camera bag"
문서 2: "waterproof action camera"
역색인
camera → [문서 1, 문서 2]
bag → [문서 1]
action → [문서 2]
선택지는 데이터 규모와 요구에 따라 달라진다.
| 요구 | 후보 |
|---|---|
| 정확한 코드 접두 검색 | B+Tree + prefix% |
| 자연어 단어 검색 | DB FULLTEXT |
| 부분 문자열·유사 검색 | n-gram/trigram 계열 인덱스 |
| 오탈자·동의어·랭킹·다국어 | 전문 검색 엔진 |
| 소규모 관리자 도구 | 제한된 %word% 스캔도 가능 |
전문 검색 엔진은 검색 기능이 강하지만 데이터 동기화, 재색인, 장애 시 fallback, eventual consistency를 운영해야 한다. 단순한 SKU 접두 검색 때문에 별도 시스템을 도입할 필요는 없다. 반대로 수천만 문서의 본문 검색을 LIKE로 버티려는 것도 적절하지 않다.
검색어 입력과 보안에서 주의할 점
LIKE 패턴을 문자열 연결로 만들면 SQL injection 위험이 생긴다.
// 잘못된 예
const sql = `SELECT * FROM products WHERE name LIKE '%${input}%'`;
반드시 파라미터 바인딩을 사용한다.
const sql = `
SELECT id, name
FROM products
WHERE name LIKE :pattern ESCAPE '\\'
LIMIT 50
`;
await database.query(sql, {
pattern: `%${escapeLikeLiteral(input)}%`,
});
바인딩은 SQL injection을 막지만 %와 _를 리터럴로 처리할지는 별도 문제다. 제품이 와일드카드를 사용자 기능으로 제공하지 않는다면 escape한다. 지나치게 짧은 패턴과 큰 결과 요청도 제한해 우발적인 전체 스캔을 방지한다.
로그에는 검색어가 개인정보나 민감한 문장을 포함할 수 있다. 원문 검색어 대신 길이, 검색 유형, 결과 수와 정규화된 query fingerprint 정도만 남기는 정책을 검토한다.
실행 계획과 테스트 데이터로 확인하기
패턴 유형별로 실행 계획을 비교한다.
EXPLAIN ANALYZE
SELECT id, sku
FROM products
WHERE sku LIKE 'CAM-2026%';
EXPLAIN ANALYZE
SELECT id, name
FROM products
WHERE name LIKE '%camera%';
다음 항목을 확인한다.
- access type이 range인가, 전체 index/table scan인가?
- 시작과 종료 범위를 만들었는가?
- 실제 읽은 엔트리와 반환 행은 몇 개인가?
- collation 변환이나 함수가 적용되었는가?
- 복합 인덱스의 앞 열 조건이 고정되었는가?
- 필터 후 버린 행과 본 테이블 lookup은 몇 개인가?
- 접두 길이가 1자, 2자, 5자일 때 분포가 어떻게 다른가?
테스트 데이터는 임의 문자열을 균등 분포로 만들지 않는다. 실제 SKU 접두, 인기 브랜드명, 한글·영문 비율, 특정 접두 쏠림을 재현해야 한다. 같은 A%라도 전체의 0.1%와 40%인 환경에서 실행 계획은 달라질 수 있다.
결론
LIKE 검색이 B+Tree 인덱스를 효율적으로 사용하는 기준은 고정된 앞부분으로 연속된 시작 범위를 만들 수 있는가에 있다. 'prefix%'는 시작 리프를 찾아 접두가 끝날 때 멈출 수 있지만, '%word%'와 '%suffix'는 첫 위치를 몰라 일반 인덱스의 한 구간으로 좁히기 어렵다.
패턴만 보고 결론내려서도 안 된다. collation이 대소문자와 악센트 의미를 결정하고, 열에 함수나 변환을 적용하면 원본 인덱스 순서를 활용하지 못할 수 있다. 복합 인덱스에서는 LIKE 앞의 등호 접두가 고정되어야 하며, 일치 비율이 높으면 range scan도 느릴 수 있다. 코드·SKU 접두는 B+Tree로, 자연어와 중간 문자열 검색은 FULLTEXT·n-gram·검색 엔진 등 요구에 맞는 자료구조로 분리하는 것이 중요하다.